sub_flow 节点引入工作流修复与首页执行工作流重构

- 引入工作流列表字段兼容 flowName,选择弹窗卡片正常展示数据
- 生成次数 maxConcurrency 同步保存到 sub_flow 节点,回显兜底恢复
- valueSource 按后端契约统一 {nodeId, fieldName},表单展示/引用正确保存与回显
- 首页执行工作流 DSL 字段路径改为 .enumValues[i],支持引用上游输出与上传文件名
This commit is contained in:
2026-08-15 18:49:56 +08:00
parent 3b71f144c4
commit 41655fa825
14 changed files with 1620 additions and 356 deletions
+38
View File
@@ -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 }>;
}
+12 -2
View File
@@ -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('');
+61 -10
View File
@@ -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>
+90 -7
View File
@@ -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 → modelRequestParamsform → 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
View File
@@ -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 时代 formConfighttp 仅 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.valueform → 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];
}
}
}
+7 -9
View File
@@ -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 就地修改 defvalue/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.subConfigruntimeShow → 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[];
}
+369 -23
View File
@@ -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 字段 = pathform 节点自定义字段 = 字段名),供首页按 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),
// 编辑器渲染归一到 uploadModelField 控件识别),保留 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 保存态 subConfigruntimeShow → 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 → 编辑器态 subFlowConfigisFormField → 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,
};