sub_flow 节点引入工作流修复与首页执行工作流重构
- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
@@ -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 }>;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,13 @@
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
<span class="tool-label">技能</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 切换会话模型入口 -->
|
||||
<button class="tool-icon-btn" :class="{ active: !!currentModelName }" title="切换会话模型" @click="emit('select-model')">
|
||||
<el-icon><Cpu /></el-icon>
|
||||
<span class="tool-label">{{ currentModelName || '设置模型' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<span class="hint-text">Shift+Enter 换行</span>
|
||||
@@ -75,7 +81,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause } from '@element-plus/icons-vue';
|
||||
import { Top, MagicStick, Promotion, Close, Paperclip, VideoPause, Cpu } from '@element-plus/icons-vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { getWorkflowList } from '/@/api/settings/creation';
|
||||
|
||||
@@ -84,12 +90,15 @@ interface Props {
|
||||
workflowLocked?: boolean;
|
||||
hideShortcuts?: boolean;
|
||||
generating?: boolean;
|
||||
// 当前会话模型名称,用于输入栏切换入口展示
|
||||
currentModelName?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||
(e: 'stop'): void;
|
||||
(e: 'select-model'): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
@@ -105,6 +114,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
workflowLocked: false,
|
||||
hideShortcuts: false,
|
||||
generating: false,
|
||||
currentModelName: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
const message = ref('');
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<el-form label-position="top" class="workflow-form">
|
||||
<template v-if="workflowDetail.nodeInputParams">
|
||||
<template v-for="node in workflowDetail.nodeInputParams" :key="node.id || node.nodeCode">
|
||||
<template v-if="node.nodeCode !== '__start__' && hasFormConfig(node)">
|
||||
<template v-if="hasFormConfig(node)">
|
||||
|
||||
<el-form-item
|
||||
v-for="field in getVisibleFields(node)"
|
||||
@@ -22,6 +22,13 @@
|
||||
:label="field.label"
|
||||
:required="field.required"
|
||||
>
|
||||
<!-- 引用其他节点输出:只读展示,值由上游节点运行时提供 -->
|
||||
<div v-if="field.valueSource" class="field-source-readonly">
|
||||
<el-tag type="info" size="small" class="source-tag">引用</el-tag>
|
||||
<span class="source-text">由「{{ getSourceDisplay(field) }}」自动提供</span>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 文本输入 -->
|
||||
<el-input
|
||||
v-if="field.type === 'input' || field.type === 'string'"
|
||||
@@ -114,6 +121,7 @@
|
||||
v-model="formValues[getFieldKey(node, field)]"
|
||||
:placeholder="field.required ? '必填' : '选填'"
|
||||
/>
|
||||
</template>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</template>
|
||||
@@ -225,6 +233,8 @@ const hasResults = computed(() => Array.isArray(props.results) && props.results.
|
||||
const hasWorkflowResults = computed(() => Array.isArray(props.results) && props.results.some((r) => r.type === 'workflow'));
|
||||
const formValues = reactive<Record<string, any>>({});
|
||||
const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({});
|
||||
// 上传字段的文件名(与 formValues 平行;单文件为字符串、多文件为数组),执行时随 value 一并写回开始节点传给后端
|
||||
const formFileNames = reactive<Record<string, string | string[]>>({});
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
@@ -236,6 +246,16 @@ const getVisibleFields = (node: any): any[] => {
|
||||
return collectHomeFormFields(node);
|
||||
};
|
||||
|
||||
// 引用来源展示:valueSource.nodeId 定位上游节点名 + 字段
|
||||
const getSourceDisplay = (field: any): string => {
|
||||
const vs = field?.valueSource;
|
||||
if (!vs || typeof vs !== 'object') return '';
|
||||
const nodes = props.workflowDetail?.nodeInputParams || [];
|
||||
const srcNode = nodes.find((n: any) => String(n?.id) === String(vs.nodeId));
|
||||
const nodeName = srcNode?.name || srcNode?.nodeName || (vs.nodeId ? `节点 ${vs.nodeId}` : '上游节点');
|
||||
return vs.field ? `${nodeName} · ${vs.field}` : nodeName;
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
return field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload';
|
||||
};
|
||||
@@ -317,12 +337,15 @@ const handleFileUpload = async (node: any, field: any, file: any) => {
|
||||
: uploadRes.data.fileURL;
|
||||
|
||||
if (!fieldFiles[key]) fieldFiles[key] = [];
|
||||
fieldFiles[key].push({ name: raw.name, url: fileUrl });
|
||||
// 文件名取服务器返回 fileName(OSS 存储名,已确认),随 value 一并写回开始节点传给后端
|
||||
fieldFiles[key].push({ name: uploadRes.data.fileName || raw.name, url: fileUrl });
|
||||
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = fileUrl;
|
||||
formFileNames[key] = fieldFiles[key][0]?.name;
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f: any) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f: any) => f.name);
|
||||
}
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '文件上传失败');
|
||||
@@ -337,8 +360,10 @@ const removeFieldFile = (node: any, field: any, fileIdx: number) => {
|
||||
fieldFiles[key].splice(fileIdx, 1);
|
||||
if (field.type === 'upload') {
|
||||
formValues[key] = '';
|
||||
formFileNames[key] = '';
|
||||
} else {
|
||||
formValues[key] = fieldFiles[key].map((f) => f.url);
|
||||
formFileNames[key] = fieldFiles[key].map((f) => f.name);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -348,23 +373,24 @@ const currentWorkflowHasPatchLayout = computed(() => {
|
||||
});
|
||||
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
return node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0;
|
||||
// 开始节点为唯一表单源(runFormFields 汇总各 model 勾选字段 + form 自定义字段)
|
||||
return String(node?.nodeCode || '').toLowerCase() === '__start__' && collectHomeFormFields(node).length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some(
|
||||
(node: any) => node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0
|
||||
);
|
||||
return props.workflowDetail.nodeInputParams.some((node: any) => hasFormConfig(node));
|
||||
});
|
||||
|
||||
const validateFormFields = (): boolean => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return true;
|
||||
for (const node of props.workflowDetail.nodeInputParams as any[]) {
|
||||
if (node.nodeCode === '__start__') continue;
|
||||
if (String(node?.nodeCode || '').toLowerCase() !== '__start__') continue;
|
||||
const fields = getVisibleFields(node);
|
||||
for (const field of fields) {
|
||||
if (!field.required) continue;
|
||||
// 引用其他节点输出的字段:值由上游节点运行时提供,非用户填写项,跳过必填校验
|
||||
if (field.valueSource) continue;
|
||||
const key = getFieldKey(node, field);
|
||||
const value = formValues[key];
|
||||
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
|
||||
@@ -382,6 +408,7 @@ watch(
|
||||
(detail) => {
|
||||
Object.keys(formValues).forEach((key) => delete formValues[key]);
|
||||
Object.keys(fieldFiles).forEach((key) => delete fieldFiles[key]);
|
||||
Object.keys(formFileNames).forEach((key) => delete formFileNames[key]);
|
||||
Object.keys(uploadingFields).forEach((key) => delete uploadingFields[key]);
|
||||
// 尝试从执行详情恢复贴片模板
|
||||
const ext = (detail as any)?.extension;
|
||||
@@ -430,10 +457,19 @@ watch(
|
||||
const rawValue = formValues[key];
|
||||
const urls = Array.isArray(rawValue) ? rawValue : rawValue ? [rawValue] : [];
|
||||
if (urls.length === 0) return;
|
||||
fieldFiles[key] = urls.map((url: string) => ({
|
||||
name: String(url || '').split('/').pop() || 'file-' + Math.random().toString(36).slice(2, 8),
|
||||
// 文件名优先取运行字段携带的服务器名(新数据),旧数据从 url 提取兜底
|
||||
const rawFn = (field as any).fileName;
|
||||
fieldFiles[key] = urls.map((url: string, i: number) => ({
|
||||
name:
|
||||
(Array.isArray(rawFn) ? rawFn[i] : i === 0 ? rawFn : undefined) ||
|
||||
String(url || '').split('/').pop() ||
|
||||
'file-' + Math.random().toString(36).slice(2, 8),
|
||||
url,
|
||||
}));
|
||||
formFileNames[key] =
|
||||
field.type === 'upload' && fieldFiles[key].length === 1
|
||||
? fieldFiles[key][0].name
|
||||
: fieldFiles[key].map((f) => f.name);
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -461,7 +497,7 @@ onMounted(() => {
|
||||
loadPlaceWorkflows();
|
||||
});
|
||||
|
||||
defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
defineExpose({ formValues, fieldFiles, formFileNames, templates, validateFormFields });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -614,6 +650,21 @@ defineExpose({ formValues, fieldFiles, templates, validateFormFields });
|
||||
}
|
||||
}
|
||||
|
||||
/* 引用字段只读展示 */
|
||||
.field-source-readonly {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
.source-tag { flex-shrink: 0; }
|
||||
.source-text { line-height: 1.5; }
|
||||
}
|
||||
|
||||
.w100 { width: 100%; }
|
||||
.chat-container { width: min(1060px, 82%); margin: 0 auto; padding: 20px 0 130px; }
|
||||
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="切换会话模型"
|
||||
width="860px"
|
||||
:close-on-click-modal="false"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 当前会话模型提示 -->
|
||||
<el-alert
|
||||
v-if="props.currentChatModelName"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="current-model-alert"
|
||||
>
|
||||
<template #title>当前会话模型:{{ props.currentChatModelName }}</template>
|
||||
</el-alert>
|
||||
|
||||
<div class="setter-header">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchKeyword" placeholder="搜索模型名称" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon> <Search /> </el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && pagedModels.length === 0" description="暂无推理模型" :image-size="100" />
|
||||
<div v-else class="model-grid">
|
||||
<div
|
||||
v-for="model in pagedModels"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
:class="{ selected: selectedModel?.id === model.id, active: String(model.id) === String(props.currentChatModelId) }"
|
||||
@click="handleSelectModel(model)"
|
||||
>
|
||||
<div class="model-card-header">
|
||||
<div class="model-name-row">
|
||||
<span class="model-name">{{ model.modelName }}</span>
|
||||
<el-tag v-if="model.systemModel" size="small" type="warning">内置</el-tag>
|
||||
<el-tag v-else size="small" type="success">我的</el-tag>
|
||||
</div>
|
||||
<div class="model-icons">
|
||||
<el-icon v-if="String(model.id) === String(props.currentChatModelId)" class="current-icon" color="#2563eb">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="model-card-body">
|
||||
<p class="model-url">{{ model.baseUrl }}</p>
|
||||
<div class="model-status">
|
||||
<el-tag :type="isModelEnabled(model) ? 'success' : 'info'" size="small">
|
||||
{{ isModelEnabled(model) ? '已启用' : '已禁用' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
:current-page="pagination.pageNum"
|
||||
:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 20, 30, 50]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
background
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="!selectedModel" :loading="saving" @click="handleConfirm">设为会话模型</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 系统内置模型:填写 API Key,经修改接口转换为用户模型并设为会话模型 -->
|
||||
<el-dialog
|
||||
v-model="apiKeyDialogVisible"
|
||||
title="填写 API Key"
|
||||
width="480px"
|
||||
append-to-body
|
||||
:close-on-click-modal="false"
|
||||
@close="handleApiKeyClose"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="api-key-alert"
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将自动转换为你的用户模型,并设为会话模型。"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKey"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入你的 API Key"
|
||||
@keyup.enter="handleApiKeyConfirm"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="handleApiKeyClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleApiKeyConfirm">确认并设置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { listModelManage, getModelManageTypes, updateModelManage, type ModelManageItem, type ModelTypeTreeNode, type UpdateModelManageParams } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
// 当前会话模型 id,用于高亮展示
|
||||
currentChatModelId?: string | number | null;
|
||||
// 当前会话模型名称,用于顶部提示
|
||||
currentChatModelName?: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'saved', model: { id: string; modelName: string }): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentChatModelId: null,
|
||||
currentChatModelName: '',
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const modelList = ref<ModelManageItem[]>([]);
|
||||
const reasoningTypes = ref<Set<number>>(new Set());
|
||||
const selectedModel = ref<ModelManageItem | null>(null);
|
||||
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
|
||||
// 系统内置模型:填 API Key → updateModelManage 转用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const pendingSystemModel = ref<ModelManageItem | null>(null);
|
||||
const apiKey = ref('');
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedModel.value = null;
|
||||
searchKeyword.value = '';
|
||||
pagination.pageNum = 1;
|
||||
fetchData();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) emit('update:modelValue', false);
|
||||
});
|
||||
|
||||
// 兼容 enabled 数字(1/0) 与布尔(true/false)
|
||||
const isModelEnabled = (model: ModelManageItem) => model.enabled === true || (model as any).enabled === 1;
|
||||
|
||||
// 将节点及其整棵子树的所有 value 加入集合
|
||||
const addSubtreeValues = (node: ModelTypeTreeNode, set: Set<number>) => {
|
||||
set.add(Number(node.value));
|
||||
for (const child of node.children || []) {
|
||||
addSubtreeValues(child, set);
|
||||
}
|
||||
};
|
||||
|
||||
// 递归遍历类型树,收集"推理"类节点的整棵子树 value 作为推理类型集合。
|
||||
// 例如 推理模型(100) 下的 101 对话/补全、102 思维链、103 函数调用 都属于推理类型,
|
||||
// 与 editModule 中"父路径标签含推理即算推理模型"的判断语义一致。
|
||||
const collectReasoningTypes = (nodes: ModelTypeTreeNode[], set: Set<number>) => {
|
||||
for (const n of nodes || []) {
|
||||
const label = String(n.label || '');
|
||||
if (label.includes('推理') || label.toLowerCase().includes('reasoning')) {
|
||||
addSubtreeValues(n, set);
|
||||
continue;
|
||||
}
|
||||
if (n.children) collectReasoningTypes(n.children, set);
|
||||
}
|
||||
};
|
||||
|
||||
// 拉取全部模型(分页合并),避免因分页截断导致前端过滤不完整
|
||||
const fetchAllModels = async () => {
|
||||
const all: ModelManageItem[] = [];
|
||||
const pageSize = 100;
|
||||
const first: any = await listModelManage({ pageNum: 1, pageSize });
|
||||
all.push(...(first?.data?.list || []));
|
||||
const total = first?.data?.total || 0;
|
||||
const pages = Math.ceil(total / pageSize);
|
||||
for (let p = 2; p <= pages; p++) {
|
||||
const r: any = await listModelManage({ pageNum: p, pageSize });
|
||||
all.push(...(r?.data?.list || []));
|
||||
}
|
||||
return all;
|
||||
};
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// 类型树
|
||||
const typeRes: any = await getModelManageTypes();
|
||||
const set = new Set<number>();
|
||||
collectReasoningTypes(typeRes?.data?.list || [], set);
|
||||
reasoningTypes.value = set;
|
||||
// 全量模型
|
||||
const all = await fetchAllModels();
|
||||
modelList.value = all.filter((m) => {
|
||||
// 推理类型集合非空时按类型过滤;集合为空(接口异常)则退回显示全部启用模型
|
||||
if (reasoningTypes.value.size > 0 && !reasoningTypes.value.has(Number(m.modelType))) return false;
|
||||
return isModelEnabled(m);
|
||||
});
|
||||
} catch {
|
||||
modelList.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredModels = computed(() => {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return modelList.value;
|
||||
return modelList.value.filter((m) => (m.modelName || '').includes(kw));
|
||||
});
|
||||
|
||||
// 本地分页切片
|
||||
const pagedModels = computed(() => {
|
||||
const start = (pagination.pageNum - 1) * pagination.pageSize;
|
||||
return filteredModels.value.slice(start, start + pagination.pageSize);
|
||||
});
|
||||
|
||||
watch(
|
||||
filteredModels,
|
||||
() => {
|
||||
pagination.total = filteredModels.value.length;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.pageNum = page;
|
||||
};
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
pagination.pageSize = size;
|
||||
pagination.pageNum = 1;
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: ModelManageItem) => {
|
||||
// 系统内置模型:先填 API Key,经修改接口转成用户模型并设为会话模型
|
||||
if (model.systemModel) {
|
||||
pendingSystemModel.value = model;
|
||||
apiKey.value = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
selectedModel.value = model;
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!selectedModel.value) return;
|
||||
await doUpdate(selectedModel.value);
|
||||
};
|
||||
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKey.value.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
if (!pendingSystemModel.value) return;
|
||||
await doUpdate(pendingSystemModel.value, apiKey.value.trim());
|
||||
};
|
||||
|
||||
// 系统模型转用户模型:需把模型完整配置传给后端,由后端复制为当前用户的模型副本
|
||||
const buildFullPayload = (model: ModelManageItem, apiKeyVal: string): UpdateModelManageParams => {
|
||||
const safeObj = (v: unknown): Record<string, unknown> | undefined =>
|
||||
v && typeof v === 'object' ? (v as Record<string, unknown>) : undefined;
|
||||
return {
|
||||
id: model.id,
|
||||
modelSupplier: Number(model.modelSupplier),
|
||||
modelName: model.modelName,
|
||||
modelType: Number(model.modelType),
|
||||
baseUrl: model.baseUrl,
|
||||
responseType: Number(model.responseType),
|
||||
apiKey: apiKeyVal,
|
||||
enabled: model.enabled,
|
||||
chatModel: true,
|
||||
maxConcurrency: model.maxConcurrency,
|
||||
maxTokens: model.maxTokens,
|
||||
tokenPredictPrice: model.tokenPredictPrice,
|
||||
requestHeadMapping: safeObj(model.requestHeadMapping),
|
||||
requestBodyMapping: safeObj(model.requestBodyMapping),
|
||||
responseMapping: safeObj(model.responseMapping),
|
||||
responseBodyMapping: (safeObj(model.responseBodyMapping) as Record<string, string>) || undefined,
|
||||
tokenMapping: model.tokenMapping,
|
||||
asyncTaskMapping: model.asyncTaskMapping,
|
||||
lastFrame: model.lastFrame || '',
|
||||
maxDuration: model.maxDuration,
|
||||
tokenPredictPriceUnit: model.tokenPredictPriceUnit || '',
|
||||
};
|
||||
};
|
||||
|
||||
const doUpdate = async (model: ModelManageItem, apiKeyVal?: string) => {
|
||||
saving.value = true;
|
||||
try {
|
||||
// 系统模型(填 API Key)转用户模型时传完整模型数据;用户自己的模型仅传 id + chatModel
|
||||
const payload: UpdateModelManageParams = apiKeyVal ? buildFullPayload(model, apiKeyVal) : { id: model.id, chatModel: true };
|
||||
const res: any = await updateModelManage(payload);
|
||||
// 系统模型转用户模型:后端返回新的 modelManage(含新 id)
|
||||
const newId = res?.data?.modelManage?.id || res?.data?.id || model.id;
|
||||
emit('saved', { id: String(newId), modelName: model.modelName });
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingSystemModel.value = null;
|
||||
selectedModel.value = null;
|
||||
visible.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '设置会话模型失败');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingSystemModel.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedModel.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.current-model-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.setter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-list {
|
||||
min-height: 300px;
|
||||
max-height: 420px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.model-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
}
|
||||
|
||||
.model-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.model-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.model-icons {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-icon,
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.model-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-url {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -65,6 +67,14 @@
|
||||
:template="pendingTemplate"
|
||||
@saved="handleTemplateSaved"
|
||||
/>
|
||||
|
||||
<!-- 会话模型设置弹窗 -->
|
||||
<SessionModelSetter
|
||||
v-model="sessionModelDialogVisible"
|
||||
:current-chat-model-id="currentChatModelId"
|
||||
:current-chat-model-name="currentChatModelName"
|
||||
@saved="handleSessionModelSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<any>(null);
|
||||
const mainContentRef = ref<any>(null);
|
||||
const sendingSessions = reactive<Record<string, boolean>>({});
|
||||
@@ -328,6 +381,13 @@ const inputBarRef = ref<any>(null);
|
||||
const templateDialogVisible = ref(false);
|
||||
const pendingTemplate = ref<any>(null);
|
||||
|
||||
// 会话模型设置弹窗
|
||||
const sessionModelDialogVisible = ref(false);
|
||||
const currentChatModelId = ref<string | number | null>(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();
|
||||
// 不自动创建 / 选中会话,默认显示占位引导页
|
||||
});
|
||||
|
||||
|
||||
+72
-226
@@ -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<T>(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<string, any>): void {
|
||||
// 首页表单值写回开始节点(唯一表单源)outputConfig 对应条目 value:
|
||||
// 1. 引用其他节点输出(valueSource)的字段只读展示,值由上游节点运行时提供,
|
||||
// 清空保存时的值快照(避免后端误用过期值)
|
||||
// 2. 上传类型字段未选文件 → 从开始节点运行字段移除,不向后端提交空字段
|
||||
export function applyHomeFormValues(nodes: any[], formValues: Record<string, any>, formFileNames?: Record<string, any>): 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,39 +32,23 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="mf-array-flat">
|
||||
<!-- 联合数组(enumValues 多模板):全部模板平铺,固定条数,不可增删 -->
|
||||
<template v-if="isVariantArray">
|
||||
<div v-for="(item, idx) in getVariantItems()" :key="idx" class="mf-variant">
|
||||
<div class="mf-variant-head">
|
||||
<!-- 数组元素直接遍历模板(enumValues/attrs):值与勾选均落在模板上,
|
||||
保存只留 enumValues 一份即可完整回显,不依赖 value 实例 -->
|
||||
<template v-for="(item, idx) in renderedArrayItems()" :key="idx">
|
||||
<template v-if="isObjectDef(item)">
|
||||
<div v-if="isVariantArray" class="mf-variant-head">
|
||||
<span class="mf-variant-name">{{ variantLabel(idx) }}</span>
|
||||
</div>
|
||||
<template v-if="isObjectDef(item)">
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
|
||||
</div>
|
||||
</template>
|
||||
<!-- 同构数组:保持现有平铺逻辑 -->
|
||||
<template v-else>
|
||||
<template v-for="(item, idx) in getArrayValue()" :key="idx">
|
||||
<template v-if="isObjectDef(item)">
|
||||
<div v-if="getArrayValue().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.value[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="item" @input="(v: any) => setArrayPrimitive(idx, v)" size="small" />
|
||||
<div v-else-if="renderedArrayItems().length > 1" class="mf-array-flat-index">#{{ idx + 1 }}</div>
|
||||
<ModelField
|
||||
v-for="(subDef, subKey) in item.attrs"
|
||||
:key="subKey"
|
||||
:field-def="subDef"
|
||||
:path="path ? `${path}.enumValues[${idx}].attrs.${subKey}` : String(subKey)"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-input v-else :model-value="getTemplatePrimitive(item)" @input="(v: any) => setTemplatePrimitive(item, v)" size="small" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,7 +161,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { FolderOpened, List, QuestionFilled, Link } from '@element-plus/icons-vue';
|
||||
import { deepClone, normalizeArrayItem } from './modelParamUtils';
|
||||
|
||||
defineOptions({ name: 'ModelField' });
|
||||
|
||||
@@ -312,22 +295,26 @@ const arrayTemplates = computed<any[]>(() => {
|
||||
return [];
|
||||
});
|
||||
|
||||
const getArrayValue = (): any[] => {
|
||||
if (!def.value) return [];
|
||||
if (!Array.isArray(def.value.value)) {
|
||||
def.value.value = [];
|
||||
}
|
||||
// value 为空时(后端仅定义结构未给内容)用模板补一条空结构,保证字段可展示、可填写
|
||||
if (def.value.value.length === 0 && arrayTemplates.value.length > 0) {
|
||||
def.value.value.push(normalizeArrayItem(deepClone(arrayTemplates.value[0])));
|
||||
}
|
||||
return def.value.value;
|
||||
};
|
||||
|
||||
const isObjectDef = (item: any) => !!item && typeof item === 'object' && item.type === 'object' && item.attrs && typeof item.attrs === 'object';
|
||||
|
||||
const setArrayPrimitive = (idx: number, val: any) => {
|
||||
getArrayValue()[idx] = val ?? '';
|
||||
// 数组渲染统一以模板(enumValues/attrs)为数据源:值与勾选均落在模板上,
|
||||
// 不受 props 同步重建 localParams(深拷贝)影响,保存只留 enumValues 一份即可完整回显
|
||||
const renderedArrayItems = (): any[] => {
|
||||
const templates = arrayTemplates.value;
|
||||
// 多模板(variant):懒填 type/role 标识值到模板(幂等),保证展示与保存有标识值
|
||||
if (templates.length > 1) templates.forEach(autoFillVariant);
|
||||
return templates;
|
||||
};
|
||||
|
||||
// 原始值数组元素:值直接读写模板原始值对象上的 value
|
||||
const getTemplatePrimitive = (item: any): any => {
|
||||
if (!item || typeof item !== 'object') return '';
|
||||
return item.value !== undefined ? item.value : item.defaultValue ?? '';
|
||||
};
|
||||
|
||||
const setTemplatePrimitive = (item: any, v: any): void => {
|
||||
if (!item || typeof item !== 'object') return;
|
||||
item.value = v ?? '';
|
||||
};
|
||||
|
||||
// ===== array:联合数组(enumValues 多模板)→ 全部模板平铺,固定条数,不可增删 =====
|
||||
@@ -366,25 +353,6 @@ const autoFillVariant = (tpl: any): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// 联合数组渲染条目:value 与 enumValues 模板按索引一一对应(懒补模板),保证固定条数
|
||||
const getVariantItems = (): any[] => {
|
||||
if (!isVariantArray.value || !def.value) return [];
|
||||
const templates = Array.isArray(def.value.enumValues) ? def.value.enumValues : [];
|
||||
if (!Array.isArray(def.value.value)) def.value.value = [];
|
||||
templates.forEach((tpl: any, i: number) => {
|
||||
const existing = def.value.value[i];
|
||||
if (!existing || typeof existing !== 'object') {
|
||||
const item = normalizeArrayItem(deepClone(tpl));
|
||||
autoFillVariant(item);
|
||||
def.value.value[i] = item;
|
||||
}
|
||||
});
|
||||
if (def.value.value.length > templates.length) {
|
||||
def.value.value = def.value.value.slice(0, templates.length);
|
||||
}
|
||||
return def.value.value;
|
||||
};
|
||||
|
||||
const variantLabel = (idx: number): string => templateLabel(def.value?.enumValues?.[idx], idx);
|
||||
</script>
|
||||
|
||||
|
||||
@@ -202,6 +202,27 @@
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- 子流程配置(仅 sub_flow 节点):选择已有工作流引入其开始参数 -->
|
||||
<template v-if="isSubFlowNode">
|
||||
<el-divider content-position="left">子流程配置</el-divider>
|
||||
<el-form-item label="引入工作流">
|
||||
<el-button type="primary" plain @click="emit('openWorkflowSelector')" style="width: 100%">选择工作流</el-button>
|
||||
<div v-if="subFlowConfig" class="selected-tag">
|
||||
<el-tag type="success" size="large" closable @close="emit('removeWorkflow')">
|
||||
{{ subFlowConfig.workflowName || subFlowConfig.workflowId }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<template v-if="subFlowConfig">
|
||||
<el-divider content-position="left">引入参数</el-divider>
|
||||
<SubFlowParams
|
||||
:fields="subFlowConfig.fields"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
@update:fields="updateSubFlowFields"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 自定义表单字段(formConfigOption,如 form 节点) -->
|
||||
<template v-if="nodeConfig?.formConfigOption">
|
||||
<el-divider content-position="left">自定义字段</el-divider>
|
||||
@@ -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<any[]>(() => props.selectedNode?.data?.runFormFields || []);
|
||||
|
||||
// 子流程节点(sub_flow):是否当前选中节点为子流程
|
||||
const isSubFlowNode = computed(() => props.selectedNode?.data?.nodeCode === 'sub_flow');
|
||||
// 子流程引入配置(编辑器态 subFlowConfig;未引入则为 null)
|
||||
const subFlowConfig = computed<any>(() => props.selectedNode?.data?.subFlowConfig || null);
|
||||
|
||||
// fieldType 可读标签
|
||||
const fieldTypeLabel = (ft: string) => {
|
||||
const map: Record<string, string> = {
|
||||
@@ -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<string, any> | 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);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div class="sub-flow-params">
|
||||
<template v-if="fields?.length">
|
||||
<!-- 薄封装:直接复用 ModelField 渲染引入参数。
|
||||
ModelField 就地写 def.value / def.valueSource / def.runtimeShow,
|
||||
fields 元素为同一引用,父组件读取同步无需额外同步。 -->
|
||||
<ModelField
|
||||
v-for="f in fields"
|
||||
:key="f.field"
|
||||
:field-def="f"
|
||||
:path="f.field"
|
||||
:upstream-nodes="upstreamNodes"
|
||||
/>
|
||||
</template>
|
||||
<el-empty v-else description="该工作流无可引入的开始参数" :image-size="60" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch } from 'vue';
|
||||
import ModelField from './ModelField.vue';
|
||||
import type { SubFlowField } from './subFlowTypes';
|
||||
|
||||
defineOptions({ name: 'SubFlowParams' });
|
||||
|
||||
interface Props {
|
||||
fields: SubFlowField[];
|
||||
upstreamNodes?: any[];
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:fields', fields: SubFlowField[]): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
// ModelField 就地修改 def(value/valueSource/runtimeShow),deep watch 通知父级刷新节点引用
|
||||
// (fields 元素为共享引用,父级无需复制数组,仅重新 emit update:selectedNode 即可)
|
||||
watch(
|
||||
() => props.fields,
|
||||
(fields) => {
|
||||
emit('update:fields', fields);
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sub-flow-params {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="选择要引入的工作流" width="900px" :close-on-click-modal="false" @close="handleClose">
|
||||
<div class="search-bar">
|
||||
<el-input v-model="searchParams.keyword" placeholder="搜索工作流名称或描述" clearable @clear="handleSearch">
|
||||
<template #prefix
|
||||
><el-icon><Search /></el-icon
|
||||
></template>
|
||||
</el-input>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
</div>
|
||||
<div class="workflow-list" v-loading="loading">
|
||||
<el-empty v-if="!loading && workflowList.length === 0" description="暂无工作流数据" :image-size="100" />
|
||||
<div v-else class="workflow-grid">
|
||||
<div
|
||||
v-for="workflow in workflowList"
|
||||
:key="workflow.id"
|
||||
class="workflow-card"
|
||||
:class="{ selected: selectedWorkflow?.id === workflow.id }"
|
||||
@click="handleSelectWorkflow(workflow)"
|
||||
>
|
||||
<div class="workflow-card-header">
|
||||
<span class="workflow-badge">我的工作流</span>
|
||||
<el-icon v-if="selectedWorkflow?.id === workflow.id" class="check-icon" color="#67c23a"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
<div class="workflow-card-body">
|
||||
<h3 class="workflow-name">{{ workflow.flowName || workflow.name || '未命名工作流' }}</h3>
|
||||
<p class="workflow-desc">{{ workflow.description || '暂无描述' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pagination.total > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.pageNum"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedWorkflow">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getSubFlowWorkflowList, type SubFlowWorkflowItem } from '/@/api/settings/workflow';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultWorkflow?: SubFlowWorkflowItem | null;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'confirm', workflow: SubFlowWorkflowItem): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultWorkflow: null,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const searchParams = reactive({ keyword: '' });
|
||||
const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const workflowList = ref<SubFlowWorkflowItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedWorkflow = ref<SubFlowWorkflowItem | null>(null);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
visible.value = val;
|
||||
if (val) {
|
||||
selectedWorkflow.value = props.defaultWorkflow || null;
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(visible, (val) => {
|
||||
if (!val) {
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
});
|
||||
|
||||
const fetchWorkflowList = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const params = {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
keyword: searchParams.keyword || undefined,
|
||||
IsOwn: true,
|
||||
};
|
||||
const res = await getSubFlowWorkflowList(params);
|
||||
const data = res.data || {};
|
||||
// 响应结构多形态容错:扁平 list 或 listFlowUserRes 嵌套
|
||||
const list = data.list ?? data.listFlowUserRes?.list ?? [];
|
||||
workflowList.value = list || [];
|
||||
pagination.total = data.total ?? data.listFlowUserRes?.total ?? workflowList.value.length;
|
||||
// 预选项仅为部分信息(切换节点后无 id)时,按名称匹配当前页以高亮
|
||||
// 名称字段兼容 flowName / name 双形态
|
||||
if (selectedWorkflow.value && !selectedWorkflow.value.id) {
|
||||
const selName = selectedWorkflow.value.flowName || selectedWorkflow.value.name;
|
||||
if (selName) {
|
||||
const matched = workflowList.value.find((w) => (w.flowName || w.name) === selName);
|
||||
if (matched) selectedWorkflow.value = matched;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
workflowList.value = [];
|
||||
pagination.total = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
pagination.pageNum = 1;
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handlePageChange = () => {
|
||||
fetchWorkflowList();
|
||||
};
|
||||
|
||||
const handleSelectWorkflow = (workflow: SubFlowWorkflowItem) => {
|
||||
selectedWorkflow.value = workflow;
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedWorkflow.value) {
|
||||
emit('confirm', selectedWorkflow.value);
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedWorkflow.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.workflow-list {
|
||||
min-height: 300px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.workflow-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.workflow-card {
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.workflow-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.workflow-card.selected {
|
||||
border-color: #67c23a;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
.workflow-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.workflow-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
.workflow-card-body {
|
||||
flex: 1;
|
||||
}
|
||||
.workflow-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 8px 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.workflow-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
min-height: 40px;
|
||||
}
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -26,6 +26,8 @@
|
||||
@open-skill-selector="showSkillSelector = true"
|
||||
@remove-skill="handleRemoveSkill"
|
||||
@update:patch-layout="handleTogglePatchLayout"
|
||||
@open-workflow-selector="showWorkflowSelector = true"
|
||||
@remove-workflow="handleRemoveWorkflow"
|
||||
/>
|
||||
|
||||
<!-- 中间:VueFlow 画布(节点库在画布内) -->
|
||||
@@ -84,6 +86,13 @@
|
||||
|
||||
<!-- 技能选择器 -->
|
||||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
|
||||
|
||||
<!-- 子流程工作流选择器 -->
|
||||
<WorkflowSelector
|
||||
v-model="showWorkflowSelector"
|
||||
:default-workflow="selectedWorkflowData"
|
||||
@confirm="handleWorkflowConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<ExposedField, 'path'> {
|
||||
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<any>(null);
|
||||
|
||||
// 子流程工作流选择器相关状态
|
||||
const showWorkflowSelector = ref(false);
|
||||
const selectedWorkflowData = ref<any>(null);
|
||||
|
||||
const deleteSelectedNode = async () => {
|
||||
if (!selectedNode.value?.data) return;
|
||||
|
||||
@@ -301,6 +323,9 @@ const onNodeClick = (event: { node: Node<NodeData, any, string> }) => {
|
||||
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<NodeData, any, string>) => {
|
||||
@@ -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<NodeData> = {
|
||||
...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<NodeData> = {
|
||||
...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<NodeData, any, string>): 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<NodeData>) => {
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
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<NodeData>, 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<string>();
|
||||
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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user