首页调试工作流管理
This commit is contained in:
@@ -52,12 +52,20 @@ export interface WorkflowModelItem {
|
||||
baseUrl?: string;
|
||||
enabled?: number | boolean;
|
||||
isOwner?: number;
|
||||
// true = 系统内置模型(选它需填 API Key 转用户模型);false/缺省 = 用户模型
|
||||
systemModel?: boolean;
|
||||
// 模型请求参数模板(选择模型后渲染为递归表单,与 DSL modelRequestParams 结构一致)
|
||||
requestBodyMapping?: Record<string, any>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export function getWorkflowModelList(params?: { pageNum: number; pageSize: number; modelName?: string }) {
|
||||
export function getWorkflowModelList(params?: {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
modelName?: string;
|
||||
isSameType?: boolean;
|
||||
modelType?: string | number;
|
||||
}) {
|
||||
return request({
|
||||
url: '/model-gateway/model/manage/listModelManage',
|
||||
method: 'get',
|
||||
|
||||
@@ -57,6 +57,7 @@
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
{{ wf.name }}
|
||||
<span v-if="wf.isTemplate" class="pill-tag">模板</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,13 +76,15 @@ interface Props {
|
||||
|
||||
interface Emits {
|
||||
(e: 'send', message: string): void;
|
||||
(e: 'workflow-select', workflowId: string | null): void;
|
||||
(e: 'workflow-select', workflowId: string | null, isTemplate?: boolean): void;
|
||||
}
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
prefix: string;
|
||||
isTemplate: boolean;
|
||||
raw?: any;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -104,11 +107,18 @@ const visibleWorkflows = computed(() => {
|
||||
const fetchWorkflows = async () => {
|
||||
try {
|
||||
const res = await getWorkflowList();
|
||||
const workflows = res.data?.listFlowUserRes?.list || [];
|
||||
const userList = res.data?.listFlowUserRes?.list || [];
|
||||
const tplList = res.data?.listFlowTemplateRes?.list || [];
|
||||
const workflows = [
|
||||
...userList.map((w) => ({ ...w, isTemplate: false })),
|
||||
...tplList.map((w) => ({ ...w, isTemplate: true })),
|
||||
];
|
||||
commonWorkflows.value = workflows.map((w) => ({
|
||||
id: String(w.id),
|
||||
name: w.flowName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || '') + ':\n',
|
||||
name: w.flowName || w.flowTemplateName || '未命名',
|
||||
prefix: '[工作流] ' + (w.flowName || w.flowTemplateName || '') + ':\n',
|
||||
isTemplate: !!w.isTemplate,
|
||||
raw: w,
|
||||
}));
|
||||
} catch {
|
||||
commonWorkflows.value = [];
|
||||
@@ -129,9 +139,10 @@ const handleAttachment = () => {
|
||||
|
||||
const toggleWorkflow = (id: string) => {
|
||||
if (props.workflowLocked) return;
|
||||
const item = commonWorkflows.value.find((w) => w.id === id);
|
||||
const newId = selectedWorkflowId.value === id ? null : id;
|
||||
selectedWorkflowId.value = newId;
|
||||
emit('workflow-select', newId);
|
||||
emit('workflow-select', newId, item?.isTemplate || false);
|
||||
};
|
||||
|
||||
const resetAll = () => {
|
||||
@@ -146,11 +157,17 @@ const resetAll = () => {
|
||||
emit('workflow-select', null);
|
||||
};
|
||||
|
||||
const selectWorkflow = (id: string | null) => {
|
||||
if (props.workflowLocked) return;
|
||||
selectedWorkflowId.value = id;
|
||||
emit('workflow-select', id, false);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchWorkflows();
|
||||
});
|
||||
|
||||
defineExpose({ resetAll, clearWorkflow, selectedWorkflowId, commonWorkflows });
|
||||
defineExpose({ resetAll, clearWorkflow, selectWorkflow, fetchWorkflows, selectedWorkflowId, commonWorkflows });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -371,4 +388,21 @@ onMounted(() => {
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.pill-tag {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
background: #fff7e6;
|
||||
color: #d97706;
|
||||
border: 1px solid #fde68a;
|
||||
white-space: nowrap;
|
||||
|
||||
.shortcut-pill.active & {
|
||||
background: rgba(255, 247, 230, 0.2);
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -190,6 +190,7 @@ import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import PatchTemplateEditor from '/@/components/patchTemplate/PatchTemplateEditor.vue';
|
||||
import { uploadFile } from '/@/api/common/upload';
|
||||
import { collectHomeFormFields } from '../utils/flowDsl';
|
||||
|
||||
interface Props {
|
||||
activeMenu: string;
|
||||
@@ -205,42 +206,12 @@ const fieldFiles = reactive<Record<string, { name: string; url: string }[]>>({})
|
||||
const uploadingFields = reactive<Record<string, boolean>>({});
|
||||
const templates = ref<any[]>([]);
|
||||
const getFieldKey = (node: any, field: any): string => {
|
||||
if (field?.__isHttpBodyChild) {
|
||||
return `${node.id}_body_${field.bodyKey}`;
|
||||
}
|
||||
return (node.id || node.nodeCode) + '_' + (field.field || field.label);
|
||||
const id = node.id || node.nodeCode;
|
||||
return `${id}|${field.path || field.field || field.label}`;
|
||||
};
|
||||
|
||||
const getVisibleFields = (node: any): any[] => {
|
||||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
||||
const result: any[] = [];
|
||||
fields.forEach((field: any) => {
|
||||
if (!field) return;
|
||||
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
|
||||
|
||||
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
|
||||
if (field.field !== 'body') return;
|
||||
const bodyVal = field.value;
|
||||
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
|
||||
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
result.push({
|
||||
__isHttpBodyChild: true,
|
||||
bodyKey,
|
||||
field: `body.${bodyKey}`,
|
||||
label: bodyItem.key || bodyKey,
|
||||
required: false,
|
||||
type: bodyItem.fieldType || 'input',
|
||||
fieldType: bodyItem.fieldType || 'string',
|
||||
fieldConstraint: bodyItem.fieldConstraint || {},
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
result.push(field);
|
||||
});
|
||||
return result;
|
||||
return collectHomeFormFields(node);
|
||||
};
|
||||
|
||||
const isFileField = (field: any): boolean => {
|
||||
@@ -355,13 +326,13 @@ const currentWorkflowHasPatchLayout = computed(() => {
|
||||
});
|
||||
|
||||
const hasFormConfig = (node: any): boolean => {
|
||||
return node.nodeCode !== '__start__' && node.formConfig && node.formConfig.length > 0;
|
||||
return node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0;
|
||||
};
|
||||
|
||||
const hasFormFields = computed(() => {
|
||||
if (!props.workflowDetail?.nodeInputParams) return false;
|
||||
return props.workflowDetail.nodeInputParams.some(
|
||||
(node: any) => node.nodeCode !== '__start__' && node.formConfig?.length > 0
|
||||
(node: any) => node.nodeCode !== '__start__' && collectHomeFormFields(node).length > 0
|
||||
);
|
||||
});
|
||||
|
||||
@@ -396,43 +367,42 @@ watch(
|
||||
templates.value = Array.isArray(restoredTemplates) ? restoredTemplates : [];
|
||||
|
||||
if (!detail?.nodeInputParams) return;
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||||
Object.entries(field.value).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||||
if (bodyItem.fieldType === 'number') {
|
||||
formValues[bodyFieldKey] = (bodyItem.value !== undefined && bodyItem.value !== null && bodyItem.value !== '')
|
||||
? Number(bodyItem.value) : null;
|
||||
} else if (bodyItem.fieldType === 'fileUpload') {
|
||||
formValues[bodyFieldKey] = Array.isArray(bodyItem.value) ? bodyItem.value
|
||||
: bodyItem.value ? [bodyItem.value] : [];
|
||||
} else {
|
||||
formValues[bodyFieldKey] = bodyItem.value || '';
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nodes = detail.nodeInputParams as any[];
|
||||
|
||||
// 1) 初始化表单值(model → modelRequestParams runtimeShow;form → outputConfig;旧数据 → formConfig)
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
const key = getFieldKey(node, field);
|
||||
const hasValue = field.value !== undefined && field.value !== null;
|
||||
const hasValue = field.value !== undefined && field.value !== null && field.value !== '';
|
||||
if (field.type === 'number' || field.type === 'inputNumber') {
|
||||
formValues[key] = hasValue ? Number(field.value) : (field.default ?? null);
|
||||
} else if (field.type === 'switch') {
|
||||
formValues[key] = hasValue ? Boolean(field.value) : (field.default ?? false);
|
||||
} else if (field.type === 'upload' || field.type === 'uploadMultiple' || field.type === 'fileUpload') {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||||
if (field.type === 'fileUpload') {
|
||||
formValues[key] = hasValue
|
||||
? Array.isArray(field.value)
|
||||
? field.value
|
||||
: field.value
|
||||
? [field.value]
|
||||
: []
|
||||
: Array.isArray(field.default)
|
||||
? field.default
|
||||
: field.default
|
||||
? [field.default]
|
||||
: [];
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? (field.type === 'upload' ? '' : []));
|
||||
}
|
||||
} else {
|
||||
formValues[key] = hasValue ? field.value : (field.default ?? '');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
detail.nodeInputParams.forEach((node: any) => {
|
||||
if (!node.formConfig) return;
|
||||
node.formConfig.forEach((field: any) => {
|
||||
// 2) 文件字段回填已上传文件列表
|
||||
nodes.forEach((node) => {
|
||||
collectHomeFormFields(node).forEach((field) => {
|
||||
if (!isFileField(field)) return;
|
||||
const key = getFieldKey(node, field);
|
||||
const rawValue = formValues[key];
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="template-complete-dialog">
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="完善系统工作流"
|
||||
width="760px"
|
||||
top="6vh"
|
||||
:close-on-click-modal="false"
|
||||
destroy-on-close
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-loading="loading" class="tc-body">
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统模板缺少运行所需配置,补全后将保存为你的个人工作流,之后可直接复用。"
|
||||
class="tc-alert"
|
||||
/>
|
||||
|
||||
<template v-if="!loading">
|
||||
<el-form label-position="top" class="tc-form">
|
||||
<el-form-item label="工作流名称">
|
||||
<el-input v-model="flowName" placeholder="请输入工作流名称" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-empty v-if="modelNodes.length === 0" description="该模板无需补全模型参数" :image-size="60" />
|
||||
|
||||
<div v-for="node in modelNodes" :key="node.id" class="tc-node">
|
||||
<div class="tc-node-title">
|
||||
<span class="tc-node-name">{{ node.name || node.nodeCode }}</span>
|
||||
<el-tag v-if="!node.modelConfig?.modelId" type="warning" size="small">未选择模型</el-tag>
|
||||
<el-tag v-else-if="node.modelConfig?.modelName" type="success" size="small">{{ node.modelConfig.modelName }}</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="tc-node-model">
|
||||
<el-button
|
||||
v-if="node.modelConfig?.modelId"
|
||||
type="warning"
|
||||
plain
|
||||
size="small"
|
||||
:loading="converting"
|
||||
@click="openApiKeyDialog(node)"
|
||||
>
|
||||
补全 API Key
|
||||
</el-button>
|
||||
<el-button type="primary" plain size="small" @click="openModelSelector(node)">
|
||||
{{ node.modelConfig?.modelId ? '重新选择模型' : '选择模型' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" :disabled="!canSave" @click="handleSave">保存并使用</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 内嵌模型选择器(自身 el-dialog 会 teleport 到 body,层级高于本弹窗) -->
|
||||
<ModelSelector
|
||||
v-model="modelSelectorVisible"
|
||||
:default-model="selectorDefaultModel"
|
||||
:same-type-model-type="selectorTargetNode?.modelConfig?.modelType"
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
<!-- 基于模板已有模型补全 API Key:系统模型 → 填 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
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并绑定到当前节点。"
|
||||
class="api-key-alert"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKeyForm.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="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import ModelSelector from '/@/views/settings/workflow/component/ModelSelector.vue';
|
||||
import { stripReadonlyFields, deepClone } from '/@/views/settings/workflow/component/modelParamUtils';
|
||||
import { getWorkflowDetail, saveWorkflow } from '/@/api/settings/creation';
|
||||
import { getModelManage, updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
template?: any; // { id, flowName, raw }
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'saved', newId: string, flowName: string): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const flowName = ref('');
|
||||
const nodes = ref<any[]>([]);
|
||||
const templateFlowContent = ref<any>(null);
|
||||
|
||||
const modelSelectorVisible = ref(false);
|
||||
const selectorTargetNode = ref<any>(null);
|
||||
const selectorDefaultModel = ref<any>(null);
|
||||
|
||||
// 补全 API Key:基于模板已有模型(系统模型)填 Key 转用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
const apiKeyTargetNode = ref<any>(null);
|
||||
const apiKeySourceModel = ref<any>(null);
|
||||
|
||||
const modelNodes = computed(() => nodes.value.filter((n) => String(n?.nodeCode || '').toLowerCase() === 'model'));
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!flowName.value.trim()) return false;
|
||||
return modelNodes.value.every((n) => !!n.modelConfig?.modelId);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
async (val) => {
|
||||
if (!val) return;
|
||||
loading.value = true;
|
||||
saving.value = false;
|
||||
flowName.value = '我的-' + (props.template?.flowName || '系统模板');
|
||||
nodes.value = [];
|
||||
templateFlowContent.value = null;
|
||||
try {
|
||||
// 优先拉取模板完整 DSL(get 接口),失败则回退列表项 flowContent
|
||||
const res = await getWorkflowDetail(props.template?.id);
|
||||
const detail = res.data;
|
||||
templateFlowContent.value = detail?.flowContent || null;
|
||||
nodes.value = JSON.parse(JSON.stringify(detail?.flowContent?.nodes || detail?.nodeInputParams || []));
|
||||
if (detail?.flowName) flowName.value = '我的-' + detail.flowName;
|
||||
} catch {
|
||||
const fc = props.template?.raw?.flowContent || props.template?.flowContent;
|
||||
templateFlowContent.value = fc || null;
|
||||
nodes.value = JSON.parse(JSON.stringify(fc?.nodes || []));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const openModelSelector = (node: any) => {
|
||||
selectorTargetNode.value = node;
|
||||
selectorDefaultModel.value = null;
|
||||
modelSelectorVisible.value = true;
|
||||
};
|
||||
|
||||
// 选模型:复刻工作流管理 handleModelConfirm 的初始化逻辑
|
||||
const handleModelConfirm = (model: any) => {
|
||||
const node = selectorTargetNode.value;
|
||||
if (node) {
|
||||
node.modelConfig = node.modelConfig || {};
|
||||
node.modelConfig.modelId = model.id || '';
|
||||
node.modelConfig.modelName = model.modelName;
|
||||
node.modelConfig.modelType = model.modelType;
|
||||
// 深拷贝模型 requestBodyMapping 作为参数模板,剔除只读字段
|
||||
node.modelConfig.modelRequestParams = stripReadonlyFields(deepClone(model.requestBodyMapping ?? null));
|
||||
node.modelConfig.modelResponseBodyMapping = model.responseBodyMapping ?? null;
|
||||
}
|
||||
modelSelectorVisible.value = false;
|
||||
selectorTargetNode.value = null;
|
||||
};
|
||||
|
||||
// 补全 API Key:拉取当前节点绑定模型详情,仅系统模型可转换
|
||||
const openApiKeyDialog = async (node: any) => {
|
||||
const modelId = node?.modelConfig?.modelId;
|
||||
if (!modelId) {
|
||||
ElMessage.warning('请先选择模型');
|
||||
return;
|
||||
}
|
||||
converting.value = true;
|
||||
try {
|
||||
const res = await getModelManage(modelId);
|
||||
const model = res?.data;
|
||||
if (!model?.id) throw new Error('模型详情获取失败');
|
||||
// 已是用户模型则无需补全
|
||||
if (model.systemModel !== true) {
|
||||
ElMessage.info('该模型已是用户模型,无需补全 API Key');
|
||||
return;
|
||||
}
|
||||
apiKeyTargetNode.value = node;
|
||||
apiKeySourceModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '获取模型详情失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 确认补全:调修改接口,后端克隆出用户模型并返回新 id,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
const node = apiKeyTargetNode.value;
|
||||
const src = apiKeySourceModel.value;
|
||||
if (!node || !src?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
const res = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||||
const newModelManage = res?.data?.modelManage || res?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 更新节点模型配置:替换为新用户模型 id,其余沿用原系统模型信息
|
||||
node.modelConfig = node.modelConfig || {};
|
||||
node.modelConfig.modelId = newModelManage.id;
|
||||
node.modelConfig.modelName = src.modelName;
|
||||
node.modelConfig.modelType = src.modelType;
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
apiKeyTargetNode.value = null;
|
||||
apiKeySourceModel.value = null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const name = flowName.value.trim();
|
||||
if (!name) {
|
||||
ElMessage.warning('请输入工作流名称');
|
||||
return;
|
||||
}
|
||||
const missing = modelNodes.value.find((n) => !n.modelConfig?.modelId);
|
||||
if (missing) {
|
||||
ElMessage.warning(`节点「${missing.name || missing.nodeCode}」未选择模型,请先选择`);
|
||||
return;
|
||||
}
|
||||
saving.value = true;
|
||||
try {
|
||||
const flowContent = {
|
||||
...(templateFlowContent.value || {}),
|
||||
nodes: nodes.value,
|
||||
};
|
||||
const res = await saveWorkflow({
|
||||
flowName: name,
|
||||
description: '',
|
||||
flowContent,
|
||||
});
|
||||
const data = res?.data;
|
||||
const newId = data?.id ?? (typeof data === 'string' || typeof data === 'number' ? data : null);
|
||||
if (!newId) throw new Error('保存失败:未返回工作流ID');
|
||||
ElMessage.success('已保存为用户工作流');
|
||||
emit('saved', String(newId), name);
|
||||
emit('update:modelValue', false);
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '保存失败,请重试');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tc-body {
|
||||
min-height: 120px;
|
||||
max-height: 62vh;
|
||||
overflow-y: auto;
|
||||
padding: 2px 4px 4px 0;
|
||||
}
|
||||
|
||||
.tc-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tc-form {
|
||||
:deep(.el-form-item) {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-node {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
background: #fbfcfe;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-node-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.tc-node-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.tc-node-model {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
+36
-34
@@ -39,6 +39,13 @@
|
||||
<el-empty v-else description="无法加载预览内容" />
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 系统模板补全弹窗 -->
|
||||
<TemplateCompleteDialog
|
||||
v-model="templateDialogVisible"
|
||||
:template="pendingTemplate"
|
||||
@saved="handleTemplateSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -48,6 +55,8 @@ import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import Sidebar from './components/Sidebar.vue';
|
||||
import MainContent from './components/MainContent.vue';
|
||||
import InputBar from './components/InputBar.vue';
|
||||
import TemplateCompleteDialog from './components/TemplateCompleteDialog.vue';
|
||||
import { applyHomeFormValues } from './utils/flowDsl';
|
||||
import type { ExecutionTreeItem } from '/@/api/settings/creation';
|
||||
import {
|
||||
getExecutionList,
|
||||
@@ -264,6 +273,8 @@ const mainContentRef = ref<any>(null);
|
||||
const sendingSessions = reactive<Record<string, boolean>>({});
|
||||
const isHistoryWorkflow = ref(false);
|
||||
const inputBarRef = ref<any>(null);
|
||||
const templateDialogVisible = ref(false);
|
||||
const pendingTemplate = ref<any>(null);
|
||||
|
||||
const isSendDisabled = computed(() => {
|
||||
if (!activeHistoryId.value) return false;
|
||||
@@ -276,11 +287,22 @@ const getSessionId = () => {
|
||||
return `session_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
||||
};
|
||||
|
||||
const handleWorkflowSelect = async (workflowId: string | null) => {
|
||||
const handleWorkflowSelect = async (workflowId: string | null, isTemplate?: boolean) => {
|
||||
if (workflowId === null) {
|
||||
selectedWorkflowDetail.value = null;
|
||||
return;
|
||||
}
|
||||
if (isTemplate) {
|
||||
// 系统模板:不跳转工作流管理,就地弹出补全弹窗,保存为用户工作流后再复用
|
||||
const item = inputBarRef.value?.commonWorkflows?.find((w: any) => w.id === workflowId) || null;
|
||||
pendingTemplate.value = {
|
||||
id: workflowId,
|
||||
flowName: item?.name || '系统模板',
|
||||
raw: item?.raw || null,
|
||||
};
|
||||
templateDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getWorkflowDetail(workflowId);
|
||||
selectedWorkflowDetail.value = res.data || null;
|
||||
@@ -289,6 +311,15 @@ const handleWorkflowSelect = async (workflowId: string | null) => {
|
||||
}
|
||||
};
|
||||
|
||||
// 模板补全保存完成:刷新列表并自动选中新保存的用户工作流
|
||||
const handleTemplateSaved = async (newId: string) => {
|
||||
templateDialogVisible.value = false;
|
||||
pendingTemplate.value = null;
|
||||
const ib = inputBarRef.value as any;
|
||||
if (ib?.fetchWorkflows) await ib.fetchWorkflows();
|
||||
ib?.selectWorkflow?.(newId);
|
||||
};
|
||||
|
||||
const handleMenuChange = (menu: string) => {
|
||||
activeMenu.value = menu;
|
||||
};
|
||||
@@ -355,38 +386,9 @@ const handleSend = async (message: string) => {
|
||||
const sessionId = curSession.sessionId || getSessionId();
|
||||
|
||||
try {
|
||||
// 1. 构建节点输入参数
|
||||
const nodeInputParams =
|
||||
selectedWorkflowDetail.value.nodeInputParams?.map((node: any) => {
|
||||
const nodeParam: any = { ...node };
|
||||
|
||||
if (node.formConfig && Array.isArray(node.formConfig)) {
|
||||
nodeParam.formConfig = node.formConfig.map((field: any) => {
|
||||
// HTTP body 处理
|
||||
if (String(node.nodeCode || '').toLowerCase() === 'http' && field.field === 'body' && field.value && typeof field.value === 'object') {
|
||||
const bodyValue = { ...field.value };
|
||||
Object.entries(bodyValue).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
const bodyFieldKey = `${node.id}_body_${bodyKey}`;
|
||||
const userVal = mc.formValues[bodyFieldKey];
|
||||
bodyValue[bodyKey] = {
|
||||
...bodyItem,
|
||||
value: userVal !== undefined ? userVal : bodyItem.value,
|
||||
};
|
||||
});
|
||||
return { ...field, value: bodyValue };
|
||||
}
|
||||
|
||||
const fieldKey = `${node.id}_${field.field || field.label}`;
|
||||
return {
|
||||
...field,
|
||||
value: mc.formValues[fieldKey] !== undefined ? mc.formValues[fieldKey] : field.value,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return nodeParam;
|
||||
}) || [];
|
||||
// 1. 构建节点输入参数:深拷贝 DSL,把首页表单值写回对应字段(model → modelRequestParams;form → outputConfig)
|
||||
const nodeInputParams = JSON.parse(JSON.stringify(selectedWorkflowDetail.value.nodeInputParams || []));
|
||||
applyHomeFormValues(nodeInputParams, mc.formValues);
|
||||
|
||||
// 2. 构建 flowContent
|
||||
const updatedFlowContent = {
|
||||
@@ -500,7 +502,7 @@ const handleSelectHistory = async (id: string) => {
|
||||
// 同步回显 InputBar 的工作流选择
|
||||
const ib = inputBarRef.value as any;
|
||||
if (ib?.commonWorkflows && res.data.flowName) {
|
||||
const match = ib.commonWorkflows.find((w: any) => w.name === res.data.flowName);
|
||||
const match = ib.commonWorkflows.find((w: any) => !w.isTemplate && w.name === res.data.flowName);
|
||||
if (match) ib.selectedWorkflowId = match.id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
// ===== 首页执行工作流管理 DSL 的解析与写回工具 =====
|
||||
// 独立实现,不依赖 settings/workflow 或 settings/creation 的旧结构。
|
||||
// 字段路径文法与工作流管理 modelParamUtils 一致:key / .attrs. / .value[i]
|
||||
|
||||
export interface HomeFormField {
|
||||
path: string;
|
||||
label: string;
|
||||
type: string;
|
||||
fieldType: string;
|
||||
required: boolean;
|
||||
value?: any;
|
||||
default?: any;
|
||||
options?: any[];
|
||||
fieldConstraint?: any;
|
||||
// 旧兼容:creation 时代 http body 中 showInForm 的子字段
|
||||
__isHttpBodyChild?: boolean;
|
||||
bodyKey?: string;
|
||||
}
|
||||
|
||||
export function deepClone<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';
|
||||
if (ft === 'number' || t === 'number') return 'number';
|
||||
if (ft === 'boolean' || t === 'boolean' || ft === 'switch') return 'switch';
|
||||
if (ft === 'select') return 'select';
|
||||
if (ft === 'textarea') return 'textarea';
|
||||
if (ft === 'upload' || ft === 'file' || ft === 'fileUpload') {
|
||||
return ft === 'uploadMultiple' || def.multiple ? 'uploadMultiple' : 'upload';
|
||||
}
|
||||
return 'input';
|
||||
}
|
||||
|
||||
// 选项归一化:兼容 {label,value} / {key,value} / value.options 嵌套三种来源
|
||||
function normalizeOptions(def: any): any[] | undefined {
|
||||
let raw: any[] | undefined;
|
||||
if (Array.isArray(def.options) && def.options.length > 0) raw = def.options;
|
||||
else if (def.value && typeof def.value === 'object' && !Array.isArray(def.value) && Array.isArray(def.value.options))
|
||||
raw = def.value.options;
|
||||
else if (Array.isArray(def.enumValues) && def.enumValues.length > 0) raw = def.enumValues;
|
||||
if (!raw) return undefined;
|
||||
return raw.map((o: any) => {
|
||||
if (o && typeof o === 'object' && !Array.isArray(o)) {
|
||||
const val = o.value !== undefined ? o.value : o.key;
|
||||
const label = o.label !== undefined ? o.label : o.key;
|
||||
return { label: label === undefined ? String(val) : label, value: val };
|
||||
}
|
||||
return { label: String(o), value: o };
|
||||
});
|
||||
}
|
||||
|
||||
// select 字段当前值:兼容 def.value 为 { value, options } 对象的特殊结构
|
||||
function leafValue(def: any): any {
|
||||
const v = def?.value;
|
||||
if (v && typeof v === 'object' && !Array.isArray(v) && 'value' in v) return v.value;
|
||||
return v;
|
||||
}
|
||||
|
||||
// model 节点:遍历 modelRequestParams 收集 runtimeShow === true 的叶子字段
|
||||
function collectModelFields(node: any): HomeFormField[] {
|
||||
const params = node?.modelConfig?.modelRequestParams;
|
||||
if (!params || typeof params !== 'object' || Array.isArray(params)) return [];
|
||||
const fields: HomeFormField[] = [];
|
||||
const walk = (def: any, prefix: string) => {
|
||||
for (const key of Object.keys(def)) {
|
||||
const f = def[key];
|
||||
if (!f || typeof f !== 'object') continue;
|
||||
const path = prefix ? `${prefix}.${key}` : key;
|
||||
const t = f.type;
|
||||
if (t === 'object') {
|
||||
if (f.attrs && typeof f.attrs === 'object' && !Array.isArray(f.attrs)) walk(f.attrs, `${path}.attrs`);
|
||||
} else if (t === 'array') {
|
||||
// 仅遍历已有实例;元素为 { type:'object', attrs } 包装时才递归
|
||||
if (Array.isArray(f.value)) {
|
||||
f.value.forEach((item: any, i: number) => {
|
||||
if (item && typeof item === 'object' && item.attrs && typeof item.attrs === 'object' && !Array.isArray(item.attrs)) {
|
||||
walk(item.attrs, `${path}.value[${i}].attrs`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} else if (f.runtimeShow === true) {
|
||||
fields.push({
|
||||
path,
|
||||
label: f.label || key,
|
||||
type: mapFieldType(f),
|
||||
fieldType: f.fieldType || 'string',
|
||||
required: !!f.required,
|
||||
value: leafValue(f),
|
||||
default: f.defaultValue,
|
||||
options: normalizeOptions(f),
|
||||
fieldConstraint: f.fieldConstraint && typeof f.fieldConstraint === 'object' ? f.fieldConstraint : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(params, '');
|
||||
return fields;
|
||||
}
|
||||
|
||||
// form 节点:outputConfig 为用户自定义运行字段
|
||||
function collectFormNodeFields(node: any): HomeFormField[] {
|
||||
const out = Array.isArray(node?.outputConfig) ? node.outputConfig : [];
|
||||
return out
|
||||
.filter((o: any) => o && typeof o === 'object' && o.field !== undefined)
|
||||
.map((o: any) => ({
|
||||
path: o.field,
|
||||
label: o.label || o.field,
|
||||
type: o.type || 'input',
|
||||
fieldType: o.type || 'string',
|
||||
required: Boolean(o.required),
|
||||
value: o.value,
|
||||
default: o.value,
|
||||
options: Array.isArray(o.options) && o.options.length > 0 ? o.options : undefined,
|
||||
fieldConstraint: o.fieldConstraint && typeof o.fieldConstraint === 'object' ? o.fieldConstraint : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
// 旧兼容:creation 时代 formConfig(http 仅 body 中 showInForm 的子字段)
|
||||
function collectLegacyFormConfig(node: any): HomeFormField[] {
|
||||
const fields = Array.isArray(node?.formConfig) ? node.formConfig : [];
|
||||
const result: HomeFormField[] = [];
|
||||
fields.forEach((field: any) => {
|
||||
if (!field) return;
|
||||
if (field.expand && typeof field.expand === 'object' && field.expand.editable === false) return;
|
||||
|
||||
if (String(node?.nodeCode || '').toLowerCase() === 'http') {
|
||||
if (field.field !== 'body') return;
|
||||
const bodyVal = field.value;
|
||||
if (!bodyVal || typeof bodyVal !== 'object' || Array.isArray(bodyVal)) return;
|
||||
Object.entries(bodyVal).forEach(([bodyKey, bodyItem]: [string, any]) => {
|
||||
if (!bodyItem || bodyItem.showInForm !== true) return;
|
||||
result.push({
|
||||
__isHttpBodyChild: true,
|
||||
bodyKey,
|
||||
path: `body.${bodyKey}`,
|
||||
label: bodyItem.key || bodyKey,
|
||||
type: bodyItem.fieldType || 'input',
|
||||
fieldType: bodyItem.fieldType || 'string',
|
||||
required: false,
|
||||
value: bodyItem.value,
|
||||
default: bodyItem.value,
|
||||
fieldConstraint: bodyItem.fieldConstraint && typeof bodyItem.fieldConstraint === 'object' ? bodyItem.fieldConstraint : undefined,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
result.push({
|
||||
path: field.field || field.label,
|
||||
label: field.label || field.field,
|
||||
type: field.type || 'input',
|
||||
fieldType: field.fieldType || field.type || 'string',
|
||||
required: Boolean(field.required),
|
||||
value: field.value,
|
||||
default: field.default,
|
||||
options: Array.isArray(field.options) ? field.options : undefined,
|
||||
fieldConstraint: field.fieldConstraint && typeof field.fieldConstraint === 'object' ? field.fieldConstraint : undefined,
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 主入口:DSL 节点 → 首页扁平表单字段
|
||||
// model → modelRequestParams runtimeShow 叶子;form → outputConfig;其余 → 旧 formConfig 兜底
|
||||
export function collectHomeFormFields(node: any): HomeFormField[] {
|
||||
const code = String(node?.nodeCode || '').toLowerCase();
|
||||
let dslFields: HomeFormField[] = [];
|
||||
if (code === 'model') dslFields = collectModelFields(node);
|
||||
else if (code === 'form') dslFields = collectFormNodeFields(node);
|
||||
// http 等其它节点:新 DSL 无运行时字段(outputConfig 为请求配置),交给旧 formConfig 兜底
|
||||
if (dslFields.length > 0) return dslFields;
|
||||
return collectLegacyFormConfig(node);
|
||||
}
|
||||
|
||||
// 首页表单值写回 DSL 对应字段(model → modelRequestParams.path.value;form → outputConfig[].value)
|
||||
export function applyHomeFormValues(nodes: any[], formValues: Record<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;
|
||||
if (code === 'model') {
|
||||
const target = resolvePath(node?.modelConfig?.modelRequestParams, f.path);
|
||||
if (target && typeof target === 'object' && !Array.isArray(target)) target.value = val;
|
||||
} else if (code === 'form') {
|
||||
const item = (node?.outputConfig || []).find((o: any) => o && o.field === f.path);
|
||||
if (item) item.value = val;
|
||||
} else if (f.__isHttpBodyChild && f.bodyKey && Array.isArray(node?.formConfig)) {
|
||||
const bodyField = node.formConfig.find((x: any) => x && x.field === 'body');
|
||||
if (bodyField?.value && typeof bodyField.value === 'object' && !Array.isArray(bodyField.value) && bodyField.value[f.bodyKey]) {
|
||||
bodyField.value[f.bodyKey].value = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模板完整性校验:返回缺模型等缺失项,供补全弹窗提示
|
||||
export function checkTemplateMissing(flowContent: any): { nodeId: string; nodeName: string; reason: string }[] {
|
||||
const nodes = Array.isArray(flowContent?.nodes) ? flowContent.nodes : [];
|
||||
const missing: { nodeId: string; nodeName: string; reason: string }[] = [];
|
||||
for (const n of nodes) {
|
||||
if (String(n?.nodeCode || '').toLowerCase() === 'model' && !n?.modelConfig?.modelId) {
|
||||
missing.push({ nodeId: n?.id || '', nodeName: n?.name || n?.nodeCode || '模型节点', reason: '未选择模型' });
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
@@ -22,7 +22,11 @@
|
||||
@click="handleSelectModel(model)"
|
||||
>
|
||||
<div class="model-card-header">
|
||||
<div class="model-type">{{ getModelTypeName(model.modelType) }}</div>
|
||||
<div class="model-type">
|
||||
{{ getModelTypeName(model.modelType) }}
|
||||
<el-tag v-if="model.systemModel" size="small" type="warning" class="model-owner-tag">内置</el-tag>
|
||||
<el-tag v-else size="small" type="success" class="model-owner-tag">我的</el-tag>
|
||||
</div>
|
||||
<el-icon v-if="selectedModel?.id === model.id" class="check-icon" color="#67c23a">
|
||||
<CircleCheck />
|
||||
</el-icon>
|
||||
@@ -56,16 +60,56 @@
|
||||
<el-button type="primary" @click="handleConfirm" :disabled="!selectedModel">确定</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
|
||||
title="该模型为系统内置模型,填写你的 API Key 后将创建一条用户模型并自动绑定到当前节点。"
|
||||
class="api-key-alert"
|
||||
/>
|
||||
<el-form label-position="top" class="api-key-form">
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="apiKeyForm.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="converting" @click="handleApiKeyConfirm">确认并转换</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import { Search, CircleCheck } from '@element-plus/icons-vue';
|
||||
import { getWorkflowModelList, type WorkflowModelItem } from '/@/api/settings/workflow';
|
||||
import { updateModelManage } from '/@/api/settings/modelConfigV2';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
defaultModel?: WorkflowModelItem | null;
|
||||
// 非空时仅列出同类型模型(首页「重新选择模型」场景);工作流管理不传,行为不变
|
||||
sameTypeModelType?: string | number | null;
|
||||
// 系统模型是否必须填写 API Key(默认 true)。
|
||||
// 超级管理员绘制/编辑模板工作流时传 false:选系统模型直接选中,不再弹 API Key。
|
||||
systemModelRequireKey?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -76,6 +120,8 @@ interface Emits {
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: false,
|
||||
defaultModel: null,
|
||||
sameTypeModelType: null,
|
||||
systemModelRequireKey: true,
|
||||
});
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
@@ -86,6 +132,11 @@ const pagination = reactive({ pageNum: 1, pageSize: 10, total: 0 });
|
||||
const modelList = ref<WorkflowModelItem[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedModel = ref<WorkflowModelItem | null>(null);
|
||||
// 系统内置模型(systemModel === true)转用户模型:填 API Key → 调修改接口,后端自动克隆出新用户模型
|
||||
const apiKeyDialogVisible = ref(false);
|
||||
const converting = ref(false);
|
||||
const pendingCloneModel = ref<WorkflowModelItem | null>(null);
|
||||
const apiKeyForm = reactive({ apiKey: '' });
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
@@ -143,6 +194,9 @@ const fetchModelList = async () => {
|
||||
pageNum: pagination.pageNum,
|
||||
pageSize: pagination.pageSize,
|
||||
modelName: searchParams.modelName || undefined,
|
||||
...(props.sameTypeModelType !== undefined && props.sameTypeModelType !== null && props.sameTypeModelType !== ''
|
||||
? { isSameType: true, modelType: props.sameTypeModelType }
|
||||
: {}),
|
||||
};
|
||||
const res = await getWorkflowModelList(params);
|
||||
modelList.value = res.data?.list || [];
|
||||
@@ -170,6 +224,14 @@ const handlePageChange = () => {
|
||||
};
|
||||
|
||||
const handleSelectModel = (model: WorkflowModelItem) => {
|
||||
// 系统内置模型:默认需填写 API Key,经修改接口转成用户模型后再绑定;
|
||||
// 超级管理员绘制模板(systemModelRequireKey=false)时直接选中,模板保留系统模型供用户使用时再补 Key
|
||||
if (model.systemModel && props.systemModelRequireKey) {
|
||||
pendingCloneModel.value = model;
|
||||
apiKeyForm.apiKey = '';
|
||||
apiKeyDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
selectedModel.value = model;
|
||||
};
|
||||
|
||||
@@ -180,6 +242,68 @@ const handleConfirm = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 确认 API Key:调用修改接口,后端克隆出用户模型并直接返回,前端立即绑定
|
||||
const handleApiKeyConfirm = async () => {
|
||||
if (!apiKeyForm.apiKey.trim()) {
|
||||
ElMessage.warning('请输入 API Key');
|
||||
return;
|
||||
}
|
||||
if (!pendingCloneModel.value?.id) return;
|
||||
converting.value = true;
|
||||
try {
|
||||
const src = pendingCloneModel.value;
|
||||
const res = await updateModelManage({
|
||||
id: src.id,
|
||||
// 传递系统模型全部配置,供后端克隆用户模型时继承
|
||||
modelName: src.modelName,
|
||||
modelType: src.modelType,
|
||||
modelSupplier: src.modelSupplier,
|
||||
baseUrl: src.baseUrl,
|
||||
responseType: src.responseType ?? src.invokeType,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
enabled: src.enabled,
|
||||
chatModel: src.ChatModel ?? src.chatModel,
|
||||
maxConcurrency: src.maxConcurrency,
|
||||
maxTokens: src.maxTokens,
|
||||
tokenPredictPrice: src.tokenPredictPrice,
|
||||
requestHeadMapping: src.requestHeadMapping ?? src.requestMapping,
|
||||
requestBodyMapping: src.requestBodyMapping,
|
||||
responseMapping: src.responseMapping,
|
||||
responseBodyMapping: src.responseBodyMapping,
|
||||
...(src.tokenMapping ? { tokenMapping: src.tokenMapping } : {}),
|
||||
...(src.asyncTaskMapping ? { asyncTaskMapping: src.asyncTaskMapping } : {}),
|
||||
...(src.lastFrame ? { lastFrame: src.lastFrame } : {}),
|
||||
...(src.maxDuration ? { maxDuration: src.maxDuration } : {}),
|
||||
...(src.tokenPredictPriceUnit ? { tokenPredictPriceUnit: src.tokenPredictPriceUnit } : {}),
|
||||
} as any);
|
||||
// 返回结构为 { data: { modelManage: {...} } },新用户模型 id 在 modelManage 中
|
||||
const newModelManage = res?.data?.modelManage || res?.data;
|
||||
if (!newModelManage?.id) throw new Error('接口未返回新的用户模型');
|
||||
// 后端返回的 modelManage 仅带新 id/apiKey,modelName/requestBodyMapping 等为空。
|
||||
// 用原系统模型完整配置 + 新 id 合并,保证绑定后模型名、参数表单、下游引用、isSameType 过滤都正常。
|
||||
const newModel: WorkflowModelItem = {
|
||||
...(pendingCloneModel.value || {}),
|
||||
id: newModelManage.id,
|
||||
systemModel: false,
|
||||
apiKey: apiKeyForm.apiKey.trim(),
|
||||
};
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
emit('confirm', newModel);
|
||||
handleClose();
|
||||
ElMessage.success('已创建用户模型并绑定');
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.message || '模型转换失败,请重试');
|
||||
} finally {
|
||||
converting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyClose = () => {
|
||||
apiKeyDialogVisible.value = false;
|
||||
pendingCloneModel.value = null;
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
visible.value = false;
|
||||
selectedModel.value = null;
|
||||
@@ -232,13 +356,19 @@ const handleClose = () => {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.model-type {
|
||||
display: inline-block;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 2px 8px;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
.model-owner-tag {
|
||||
margin-left: 2px;
|
||||
}
|
||||
}
|
||||
.check-icon {
|
||||
font-size: 20px;
|
||||
@@ -272,4 +402,12 @@ const handleClose = () => {
|
||||
justify-content: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-key-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.api-key-form {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -74,8 +74,13 @@
|
||||
@confirm="confirmSaveWorkflow"
|
||||
/>
|
||||
|
||||
<!-- 模型选择器 -->
|
||||
<ModelSelector v-model="showModelSelector" :default-model="selectedModelData" @confirm="handleModelConfirm" />
|
||||
<!-- 模型选择器(管理员绘制模板时选系统模型无需填 API Key,直接选中) -->
|
||||
<ModelSelector
|
||||
v-model="showModelSelector"
|
||||
:default-model="selectedModelData"
|
||||
:system-model-require-key="!isSuperAdmin"
|
||||
@confirm="handleModelConfirm"
|
||||
/>
|
||||
|
||||
<!-- 技能选择器 -->
|
||||
<SkillSelector v-model="showSkillSelector" :default-skill="selectedSkillData" @confirm="handleSkillConfirm" />
|
||||
@@ -97,6 +102,7 @@ import {
|
||||
deleteWorkflow,
|
||||
type WorkflowItem,
|
||||
} from '/@/api/settings/creation';
|
||||
import { checkIsSuperAdmin } from '/@/api/system/user';
|
||||
import { getNodeLibraryList, type NodeLibraryGroup } from '/@/api/settings/workflow';
|
||||
import NodeConfigPanel from './component/NodeConfigPanel.vue';
|
||||
import {
|
||||
@@ -226,6 +232,8 @@ let nodeId = 0;
|
||||
// 模型选择器相关状态
|
||||
const showModelSelector = ref(false);
|
||||
const selectedModelData = ref<any>(null);
|
||||
// 当前用户是否为超级管理员(管理员绘制/编辑模板工作流时选系统模型无需填 API Key)
|
||||
const isSuperAdmin = ref(false);
|
||||
|
||||
// 技能选择器相关状态
|
||||
const showSkillSelector = ref(false);
|
||||
@@ -270,9 +278,10 @@ const onConnect = (connection: Connection) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// id 含 source/target/handle,同一对节点间不同 handle 的连线也互不冲突
|
||||
addEdges([
|
||||
{
|
||||
id: `edge-${connection.source}-${connection.target}`,
|
||||
id: `edge-${connection.source}-${connection.sourceHandle || 'source-bottom'}-${connection.target}-${connection.targetHandle || 'target-top'}`,
|
||||
source: connection.source,
|
||||
target: connection.target,
|
||||
sourceHandle: connection.sourceHandle,
|
||||
@@ -695,28 +704,33 @@ const fetchWorkflowList = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 添加默认开始节点
|
||||
// 构造默认开始节点(固定 id,供新建/清空画布后恢复开始节点)
|
||||
const createStartNode = () => ({
|
||||
id: 'start-node',
|
||||
type: 'input',
|
||||
position: { x: 200, y: 200 },
|
||||
data: { label: '开始', nodeCode: '__start__', runFormFields: [] },
|
||||
});
|
||||
|
||||
// 添加默认开始节点(onMounted 首次初始化用 addNodes,此时画布为空、无 v-model watch 竞争)
|
||||
const addDefaultStartNode = () => {
|
||||
const startNode = {
|
||||
id: 'start-node',
|
||||
type: 'input',
|
||||
position: { x: 200, y: 200 },
|
||||
data: { label: '开始', nodeCode: '__start__', runFormFields: [] },
|
||||
};
|
||||
addNodes([startNode]);
|
||||
nodes.value.push(startNode);
|
||||
addNodes([createStartNode()]);
|
||||
};
|
||||
|
||||
// 新建工作流
|
||||
const createNewWorkflow = () => {
|
||||
currentEditingWorkflowId.value = null;
|
||||
nodes.value = [];
|
||||
edges.value = [];
|
||||
selectedNode.value = null;
|
||||
nodeId = 0;
|
||||
|
||||
// 添加默认开始节点
|
||||
addDefaultStartNode();
|
||||
// 重置保存表单,避免新建后点保存还带着上次编辑/未保存的名称描述
|
||||
saveForm.value.flowName = '';
|
||||
saveForm.value.description = '';
|
||||
|
||||
// 直接整体赋值 v-model(nodes/edges),不再混用 removeNodes/addNodes 直接改 store,
|
||||
// 避免 VueFlow 双向 watch(外部↔store)竞争导致开始节点丢失
|
||||
nodes.value = [createStartNode()];
|
||||
edges.value = [];
|
||||
|
||||
ElMessage.success('已清空画布,可以开始创建新工作流');
|
||||
};
|
||||
@@ -773,7 +787,7 @@ const buildNodeFormConfigFromDsl = (n: any) => {
|
||||
});
|
||||
}
|
||||
// 新格式:HTTP 节点 outputConfig 为 {type,field,label,value} 数组
|
||||
if (Array.isArray(n.outputConfig) && n.outputConfig.length > 0) {
|
||||
if (Array.isArray(n.outputConfig) && n.outputConfig.length > 0 && n.nodeCode !== '__start__') {
|
||||
const defs = nodeConfigMap.value.get(n.nodeCode)?.formConfig || [];
|
||||
// 无 presetOption 定义(如 form 节点为命名值数组)时直接透传
|
||||
if (defs.length === 0) return n.outputConfig;
|
||||
@@ -799,7 +813,7 @@ const buildNodeFormConfigFromDsl = (n: any) => {
|
||||
return null;
|
||||
};
|
||||
|
||||
// 构建保存用的 outputConfig(HTTP/form 节点),其余返回 null
|
||||
// 构建保存用的 outputConfig(HTTP/form 节点),其余返回 null(开始节点的运行表单字段由保存处单独写入)
|
||||
const buildOutputConfig = (node: Node<NodeData>) => {
|
||||
const nodeCode = node.data?.nodeCode || '';
|
||||
if (nodeCode === 'http') {
|
||||
@@ -855,14 +869,26 @@ const buildModelConfigFromDsl = (n: any) => {
|
||||
}
|
||||
return {
|
||||
modelId: n.modelConfig?.modelId || '',
|
||||
modelName: '',
|
||||
modelType: undefined,
|
||||
// 回读 DSL 中保存的模型名称/类型(旧 DSL 无这些字段时为空,兼容)
|
||||
modelName: n.modelConfig?.modelName || '',
|
||||
modelType: n.modelConfig?.modelType ?? undefined,
|
||||
modelRequestParams,
|
||||
modelFormFields,
|
||||
modelResponseBodyMapping: n.modelConfig?.modelResponseBodyMapping ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
// 从 edge id 反向解析 handle:edge id 形如 edge-{source}-{sourceHandle}-{target}-{targetHandle},
|
||||
// 后端保存时可能丢弃 sourceHandle/targetHandle,用 id 兜底还原,保证连线回显位置正确。
|
||||
const parseEdgeHandleFromId = (id: string) => {
|
||||
const sourceMatch = id.match(/^edge-.+-source-(right|bottom)-.+$/);
|
||||
const targetMatch = id.match(/-target-(top|left)$/);
|
||||
return {
|
||||
sourceHandle: sourceMatch ? `source-${sourceMatch[1]}` : undefined,
|
||||
targetHandle: targetMatch ? `target-${targetMatch[1]}` : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// 从 DSL 加载工作流
|
||||
const loadWorkflowFromDsl = (dsl: any) => {
|
||||
if (!dsl) return;
|
||||
@@ -886,21 +912,33 @@ const loadWorkflowFromDsl = (dsl: any) => {
|
||||
patchLayout: n.patchLayout || false,
|
||||
isSaveFile: Boolean(n.isSaveFile),
|
||||
preTool: n.preTool ?? null,
|
||||
...(isStart ? { runFormFields: Array.isArray(n.runFormFields) ? n.runFormFields : [] } : {}),
|
||||
// 开始节点运行表单字段:新格式存 outputConfig;旧 DSL 顶层 runFormFields 兜底兼容
|
||||
...(isStart
|
||||
? {
|
||||
runFormFields: Array.isArray(n.outputConfig)
|
||||
? n.outputConfig
|
||||
: Array.isArray(n.runFormFields)
|
||||
? n.runFormFields
|
||||
: [],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const loadedEdges = (dsl.edges || []).map((e: any) => ({
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: e.sourceHandle || 'source-bottom',
|
||||
targetHandle: e.targetHandle || 'target-top',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
}));
|
||||
const loadedEdges = (dsl.edges || []).map((e: any) => {
|
||||
const fromId = parseEdgeHandleFromId(e.id || '');
|
||||
return {
|
||||
id: e.id,
|
||||
source: e.from,
|
||||
target: e.to,
|
||||
sourceHandle: e.sourceHandle || fromId.sourceHandle || 'source-bottom',
|
||||
targetHandle: e.targetHandle || fromId.targetHandle || 'target-top',
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
style: { stroke: '#3b82f6', strokeWidth: 2 },
|
||||
};
|
||||
});
|
||||
|
||||
nodes.value = loadedNodes;
|
||||
edges.value = loadedEdges;
|
||||
@@ -965,6 +1003,11 @@ const confirmSaveWorkflow = async () => {
|
||||
subConfig: null,
|
||||
modelConfig: {
|
||||
modelId: n.data?.modelConfig?.modelId || '',
|
||||
// 模型名称/类型随工作流保存,供首页补全弹窗展示模型名与「同类型模型」过滤
|
||||
...(n.data?.modelConfig?.modelName ? { modelName: n.data.modelConfig.modelName } : {}),
|
||||
...(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
|
||||
@@ -973,12 +1016,12 @@ const confirmSaveWorkflow = async () => {
|
||||
// 模型返回参数随工作流保存,保证重开后仍可被下游引用
|
||||
modelResponseBodyMapping: n.data?.modelConfig?.modelResponseBodyMapping ?? null,
|
||||
},
|
||||
outputConfig: buildOutputConfig(n),
|
||||
// 开始节点:运行表单字段存入 outputConfig(不再单独存顶层 runFormFields 字段)
|
||||
outputConfig: isStartNode(n) ? (n.data?.runFormFields ?? []) : buildOutputConfig(n),
|
||||
...(n.data?.skillName ? { skillName: n.data.skillName } : {}),
|
||||
...(n.data?.prompt ? { prompt: n.data.prompt } : {}),
|
||||
...(n.data?.negativePrompt ? { negativePrompt: n.data.negativePrompt } : {}),
|
||||
...(n.data?.patchLayout ? { patchLayout: n.data.patchLayout } : {}),
|
||||
...(isStartNode(n) ? { runFormFields: n.data?.runFormFields ?? [] } : {}),
|
||||
outputResult: null,
|
||||
};
|
||||
}),
|
||||
@@ -1037,6 +1080,12 @@ const deleteWorkflowAction = async (workflow: WorkflowItem) => {
|
||||
currentEditingWorkflowId.value = null;
|
||||
saveForm.value.flowName = '';
|
||||
saveForm.value.description = '';
|
||||
// 删除的是当前编辑中的工作流时,恢复画布到默认开始节点,避免旧节点/连线残留。
|
||||
// 整体赋值 v-model,与 createNewWorkflow 一致,避免直接改 store 触发 watch 竞争
|
||||
selectedNode.value = null;
|
||||
nodeId = 0;
|
||||
nodes.value = [createStartNode()];
|
||||
edges.value = [];
|
||||
}
|
||||
|
||||
await fetchWorkflowList();
|
||||
@@ -1052,6 +1101,14 @@ onMounted(async () => {
|
||||
await getNodeLibrary();
|
||||
await fetchWorkflowList();
|
||||
|
||||
// 获取当前用户是否为超级管理员(管理员绘制模板时选系统模型无需填 API Key)
|
||||
try {
|
||||
const res: any = await checkIsSuperAdmin();
|
||||
isSuperAdmin.value = res.data?.isSuperAdmin || false;
|
||||
} catch {
|
||||
isSuperAdmin.value = false;
|
||||
}
|
||||
|
||||
// 添加默认开始节点(固定在页面中间偏左)
|
||||
addDefaultStartNode();
|
||||
nodeId = 0;
|
||||
|
||||
Reference in New Issue
Block a user