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
+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>